Expose active goal in stream JSON#4314
Conversation
📋 Review SummaryThis PR adds support for emitting active goal updates as stream JSON events in headless/non-interactive mode, along with improved 🔍 General Feedback
🎯 Specific Feedback🔴 CriticalNo critical issues identified. 🟡 HighNo high priority issues identified. 🟢 Medium
🔵 Low
✅ Highlights
|
pomelo-nwu
left a comment
There was a problem hiding this comment.
Scope is tight (+149/-1, focused on protocol surface + a small UX fix on top of #4273). Reading the code statically — did not run the test suite locally.
What looks good
- Type definition is consistent.
ActiveGoalStreamEvent { type: 'active_goal', active_goal: ActiveGoal | null }intypes.tsmirrors the snake_case convention used bytool_progress/content_block_*, andActiveGoal | nullaccurately reflectsServerGeminiActiveGoalEvent.valueincore/turn.ts. processEventoverride reuses the existing guard. CallingemitStreamEventIfEnabledautomatically respectsincludePartialMessages, so there is no duplicated condition. Other events go throughsuper.processEvent(event)unchanged.- Nice UX side-fix in
goalCommand.ts. Before this PR, non-interactive/goal clearreturnedvoid, whichhandleSlashCommandfell back to as"Command executed successfully."(becausecreateNonInteractiveUI().addItemis a no-op). Returning an explicitGoal cleared: <condition>is a clear improvement. - Tests cover the meaningful axes. The stream-json test asserts both the populated
ActiveGoaland thenull(cleared) emission; the CLI tests cover empty/goal, set-then-status, and clear, and properly isolate the module-scoped store via__resetActiveGoalStoreForTests.
Suggestions
-
Consider ACP mode in
goalCommand.tsas well. The new branch is gated onexecutionMode === 'non_interactive', but ACP mode also goes throughhandleSlashCommandwith the same no-opcreateNonInteractiveUI(), andSession.ts#processSlashCommandResultalready handlesmessageType: 'info'by emitting an agent message chunk to Zed. So ACP users currently get the same"Command executed successfully."fallback. Loosening the check would unify the behavior:if (context.executionMode !== 'interactive') { return infoMessage(`Goal cleared: ${cleared.condition}`); }
-
Optional: add a negative test for the disabled path. The "with partial messages disabled" describe block in
StreamJsonOutputAdapter.test.tsdoesn't assert thatprocessEvent({ type: ActiveGoal, ... })is suppressed.emitStreamEventIfEnabledalready guards onincludePartialMessages, but a single assertion would lock the behavior against future regressions. -
Minor architectural note (non-blocking).
BaseJsonOutputAdapterexposes hooks likeonTextBlockCreated/onToolUseBlockCreatedand keeps state mutation in the baseswitch. Overriding the top-levelprocessEventhere is fine becauseActiveGoaldoesn't touch message state — it's a pure side-channel event. If more side-channel events land later (e.g. hook status), it might be worth introducing anonSideChannelEventhook in the base class for symmetry. Not needed for this PR.
Risks
- Protocol-level: purely additive change to the
stream_eventdiscriminated union. Consumers doing exhaustive type-narrowing will need to handle the new variant, but that is the intended forward-compatible shape. state.finalizedguard: the baseprocessEventshort-circuits whenstate.finalizedis true. The override doesn't replicate that check, but per the emit sites incore/client.tsallActiveGoalyields happen inside the turn beforeFinished, so this is safe today. Worth keeping in mind if the lifecycle ordering ever changes.
Overall looks good — I'd suggest folding in the ACP branch unification before merge, the rest is optional polish.
wenshao
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI failing: Test (ubuntu-latest, Node 22.x). — gpt-5.5 via Qwen Code /review
Local maintainer validation — all gates green ✅Reviewed at head Environment
Results
End-to-end proof (built artifact, not source)Drove the compiled adapter with the exact event shape Set goal → expected stream event (partial=on): {
"type": "stream_event",
"uuid": "0eab8d68-…",
"session_id": "e2e-session",
"parent_tool_use_id": null,
"event": {
"type": "active_goal",
"active_goal": {
"condition": "finish the refactor",
"iterations": 2,
"setAt": 123,
"tokensAtStart": 456,
"hookId": "goal-hook-id",
"lastReason": "still missing verification"
}
}
}Goal cleared → expected stream event (partial=on): {
"type": "stream_event",
"uuid": "4c0df0b8-…",
"session_id": "e2e-session",
"parent_tool_use_id": null,
"event": {
"type": "active_goal",
"active_goal": null
}
}Without Behavioral observations
Notes for the merge decision
— Maintainer local validation, run on |
pomelo-nwu
left a comment
There was a problem hiding this comment.
Review: Approve
Product direction — sound. /goal drives the agent to keep iterating via a session-scoped Stop hook. Headless / stream-json / ACP consumers previously had no way to observe active-goal state (active? how many iterations? last judge reason?). Exposing ActiveGoal as an active_goal stream event, plus adding acp support to /goal, is a reasonable protocol-layer completion of the existing non-interactive goal capability (#4273).
Code quality — good.
- The deliberate bypass of the finalized guard is necessary and correct.
maybeEmitActiveGoalChangeyieldsActiveGoalevents after the Stop hook runs (client.ts), by which point the main message is usually finalized; the baseprocessEventdrops events oncestate.finalizedis true. InterceptingActiveGoalbeforesuperis exactly what lets late goal-state changes reach consumers. SinceActiveGoalnever touchesmainAgentMessageState, the bypass does not corrupt the message state machine. The comment explains the why well. - Types line up:
ActiveGoalStreamEvent.active_goal: ActiveGoal | nullpassesevent.valuethrough directly, and theActiveGoalfields (condition / iterations / setAt / tokensAtStart / lastReason? / hookId) match the test payload exactly. - Correctly silent when partial messages are disabled (covered by a test).
- The clear-message change only returns an info message outside interactive mode, leaving the interactive history-card path intact.
Please confirm (non-blocking):
- ACP set->drive end-to-end: this PR adds
'acp'tosupportedModes, but the set branch still doesaddItem(setItem)+ returnssubmit_prompt. Tests only cover acp clear, not whether/goal <condition>under acp actually gets the host (Zed) to consumesubmit_promptand kick off the first turn. A short note or an acp-set assertion would close the gap — otherwise it may "register the hook but not auto-start". - Minor consistency: clear now returns a "Goal cleared: …" text receipt, but a successful set in non-interactive / acp has no "Goal set" text (it relies on the following prompt submission as implicit confirmation). A design tradeoff; fine to leave.
中文说明
评审结论:通过(Approve)
产品方向 —— 合理。 /goal 靠 session 级 Stop hook 驱动 agent 持续迭代。此前 headless / stream-json / ACP 消费者无法感知 active-goal 状态(是否激活?迭代几轮?上次判定理由?)。把 ActiveGoal 作为 active_goal stream 事件暴露,并让 /goal 支持 acp 模式,是对已有 non-interactive goal 能力(#4273)的协议层补齐,方向正确。
代码质量 —— 好。
- 绕过 finalized guard 的设计是必要且正确的。
maybeEmitActiveGoalChange在 Stop hook 执行之后才 yieldActiveGoal事件(client.ts),此刻主消息通常已 finalize;而 baseprocessEvent在state.finalized为真时会丢弃事件。在super之前拦截ActiveGoal,正好让晚期 goal 状态变更能送达消费者。由于ActiveGoal不触碰mainAgentMessageState,绕过不会破坏消息状态机。注释把 why 写清楚了。 - 类型吻合:
ActiveGoalStreamEvent.active_goal: ActiveGoal | null直接透传event.value,ActiveGoal字段与测试 payload 完全一致。 - partial messages 关闭时正确静默(有测试覆盖)。
- clear 的文本回执只在非交互模式返回,不影响交互模式的 history card。
需作者确认(不阻塞):
- ACP set->驱动端到端:本 PR 把
'acp'加进supportedModes,但 set 分支仍是addItem(setItem)+ 返回submit_prompt。测试只覆盖了 acp 的 clear,没覆盖 acp 下/goal <condition>是否真能让宿主(Zed)消费submit_prompt起跑首轮。补一句说明或一个 acp-set 断言即可补上 —— 否则可能"注册了 hook 但没自动开跑"。 - 一致性小点:clear 现在有 "Goal cleared: …" 文本回执,但 set 成功在非交互 / acp 下无 "Goal set" 文本(靠紧随的 prompt 提交隐式确认)。属设计取舍,可不改。
Summary
Validation
Notes